iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Modern Web

現代函式庫與JavaScript的關係系列 第 8

Day 8|瀏覽器拿到的只是一串純文字——從 打包到 HTML之Parsing、JS之V8如何編譯 以及script 標籤不同屬性值載入。三大框架共同的底層

  • 分享至 

  • xImage
  •  

【打包 buildtime】前端開發工具總覽:打包、轉譯、Lint 與 Parser

用詞約定:buildtime 說「轉譯」,runtime 才說「編譯」

buildtime 全流程地圖:每一步做了什麼、主場在哪一篇

# buildtime 的一步 實際做了什麼 主場筆記
1 觸發 你打 npm run buildrun 是 npm 的子指令,意思是「去 scripts 物件找這個 key,把對應字串丟給系統 shell 執行」,不是 npm 自動幫你加的 [[08-npm-run-script-mechanism]]
2 找得到工具 npm 執行 script 前會把 node_modules/.bin 塞進 PATH,所以 "build": "vite build" 可以直接寫套件名、不用寫完整路徑 [[09-npm-scripts-pre-post-生命週期鉤子]] 第 l 點
3 前置鉤子 prebuild npm 自動先跑同名 pre 腳本(常見用途:rimraf dist 清掉上次產物、先跑一次 lint 或 type check) [[09-npm-scripts-pre-post-生命週期鉤子]] 第 e、f 點
4 建立依賴圖(其實跟轉譯是交織在一起的) bundler 從入口檔(main.tsxindex.js)開始:先用 parser(如 acorn)parse 每個檔、讀出它有哪些 import,再用 resolver(如 webpack 的 enhanced-resolve、Rollup 的 @rollup/plugin-node-resolve)resolve 那些 import 指到的實際檔案(含去 node_modules 找第三方),一路遞迴掃出整張 module graph。parse 與 resolve 一樣重要:一個讀出「要什麼」、一個找出「在哪」 本篇第 1、6 節
5 轉譯 transpile Babel/tsc/SWC 把 TSX、JSX、新語法轉成標準 JS。方向是高階→高階,所以叫轉譯不叫編譯 本篇第 1 節(5 步表)、6 節
6 品質把關 ESLint 抓邏輯問題與潛在 bug,Prettier 統一格式。實務上常掛在 prebuild 或 CI,不是打包器本身的職責 本篇第 3 節 + 上面第 3 步
7 壓縮 minify 移除註解、空白、換行,縮短變數名 本篇第 1 節(5 步表)
8 打包 bundle 把多個模組合併成少數幾支檔案,做 code splitting 與帶 hash 的檔名 本篇第 1 節(5 步表) + [[10-next-turbopack-server-chunks-hash-comparison]]
9 後置鉤子 postbuild build 成功結束才跑;build 失敗(非 0 退出碼)時不會執行 [[09-npm-scripts-pre-post-生命週期鉤子]] 第 f 點
10 交棒 產物丟上伺服器。buildtime 到這裡就結束了,瀏覽器下載到的是第 8 步的產物,之後才輪到 V8 的 runtime 編譯 [[04-V8引擎完整管線-Parse到Deoptimization-【編譯runtime】|04-V8引擎完整管線(編譯 runtime)]]

[!tip]- 一句話記法
08 教你 run 怎麼找到指令 → 09 教你指令前後還會偷跑什麼 → 03(本篇)教你被跑起來的那些工具各自在做什麼 → 04 才是瀏覽器拿到產物之後的事。

各工具在流水線的哪一階(對照最上面的全流程地圖)

1. 打包(build)到底做了什麼——詳細 5 步(含 content hash)

為什麼要打包:開發碼含偵錯工具、未壓縮、好讀註解,且瀏覽器不認得 JSX/TS;build 時要處理成瀏覽器能吃的靜態檔。這 5 步就是最上面地圖第 4–8、10 步的細節(跟 [[script載入方式+前因後果]] 的打包 5 步一致):

分兩階段:A 內部交織、不分先後;B 才有順序(所以不硬編 1→2→3→4→5)。

階段 動作 誰做 做什麼 → 產物
A. 分析+轉譯(同一趟、交織) 建相依圖 bundler core acorn parse 找 import + enhanced-resolve resolve 找實際檔(含 node_modules)→ module graph
A(同一趟) 逐檔 transpile Babel/SWC/esbuild 每讀到一個 .tsx/.jsx 就轉成標準 JS(否則 parse 不出它的 import)→ 純文字 JS
B. 合成+輸出(有先後) bundle bundler core 合併成 chunk + tree-shaking(砍死碼)+ code-splitting(切 vendor/lazy)+ scope-hoisting → 少數幾支 JS
B minify Terser/esbuild/SWC 去空白/註解、縮短變數名 → 更小的 JS
B hash + 產出 bundler core 檔名帶 content hashmain-a1b2c3.js)+把 <script>/<link> 注入 index.htmldist/ 靜態檔

記法:與其背 ①→②→③→④→⑤,不如記兩個階段——A「先一趟把碼讀懂+統一成標準 JS」(建圖與轉譯交織、同一趟,不是先做完①才做②)→ B「再合成輸出」(bundle→minify→hash,這三個才真的有先後)。

content hash 為什麼是必要的第 5 步(你上次問的):內容沒變→檔名不變→瀏覽器直接用快取;內容一變→檔名就變→強制重抓(cache busting)。這不是「打包三大任務」漏寫,而是本來就有的第 5 步。

為什麼「建圖①」後馬上「轉譯②」、又擺在 bundle/minify 前面?

  • 轉譯不是「去掉東西」——去死碼是③ tree-shaking、去空白是④ minify。轉譯是把語法翻成標準 JSJSX→createElementTS→JS;唯一算「去掉」的是 TS 型別註記)。
  • 建圖①與轉譯②其實交織、同一趟走訪:bundler 要建圖得讀每個檔的 import,但 .tsx/.jsx 不是合法標準 JS,每讀到一個檔就先轉譯成標準 JS,才 parse 得出它的 import、把圖往下接。所以是「邊建圖邊逐檔轉譯」,不是整張圖建完才轉。
  • 轉譯要(在③④前):因為 tree-shaking 要分析 ES import/export、minify 要處理 JS——都得先把大家統一成標準 JS 才做得了。

2. 誰來做這 5 步(工具比較)

開發碼未壓縮、好讀;為上線快又穩,用打包工具做「生產(production)打包」——也就是上面那 5 步。下表比較各工具。

[!note] 看到「This page is using the production build of React」就代表:這網頁用的是經打包工具優化後的 React,已準備好上線,而非開發模式。

工具(問世) 語言 類型 特點 / 2026 現況
Webpack(2012) JS bundler 歷史悠久、生態成熟、所有框架通用;但 HMR 較慢、需大量 Loaders/Plugins;下載量仍最多但新專案少選
Rollup(2015) JS bundler 推廣 tree-shaking、ESM-first;早期偏函式庫打包;曾是 Vite 生產打包的底層
esbuild(2020) Go transpiler/bundler 極快,多被當「其他工具內部的轉譯器」;Vite 開發期用它做預轉譯
Vite(2020) JS(底層借 Go/Rust) build tool(含 dev server+bundler) 開發用原生 ESM+esbuild 不 bundle、生產用 Rollup(Vite v8 起改 Rolldown);2026 新專案預設
Turbopack(2022) Rust bundler Vercel 做、增量只重跑變動部分;Next.js 專用,next dev --turbo
Rolldown(2024→Vite v8, 2026) Rust bundler Rollup 的 Rust 重寫;Vite v8 起取代 esbuild+Rollup
Next.js(2016) —(JS 框架) 框架,不是 bundler 底層用 Turbopack/Webpack 這些 bundler,另外加路由/SSR/資料抓取

⚠️ 主詞分清:Webpack/Rollup/esbuild/Turbopack/Rolldown 是 bundler(打包器)Vite 是 build tool(把 bundler + dev server 包成一套);Next.js 是框架(把某個 bundler 再包一層、加路由/SSR)。所以「為何不比較 Next.js」——它跟前面那些不是同一層,它是它們的人。

表格裡你看不懂的三點,講清楚:

  • esbuild 為什麼身兼 transpiler/bundler 二職:esbuild 內部同時實作了 parser + transpiler + bundler + minifier(Go 寫、極快),所以它單獨就能 transpile、也能 bundle。只是很多工具只借它的一部分——例如 Vite 開發期只用它「預轉譯依賴」那塊、不用它 bundle。
  • 「Vite v8 起取代 esbuild + Rollup」是什麼意思Vite ≤7 內部用兩個工具——開發期用 esbuild(預轉譯依賴)、生產期用 Rollup(做 bundle);Vite v8(2026) 改用一個 Rust 工具 Rolldown 把這兩份活一起做。所以答案:Vite ≤7 生產用 Rollup;Vite v8+ 生產用 Rolldown。
  • Turbopack 有贏過 Rolldown 嗎:兩者定位不同、不會在同一專案二選一——Turbopack 綁 Next.jsRolldown 綁 Vite。速度上都是 Rust、都很快,benchmark 互有勝負、隨版本一直變,沒有絕對贏家;別記「誰贏」,記「各自綁哪個生態」。

3. ESLint vs Prettier(程式碼品質工具)

兩者解決的問題完全不同、常一起用。左右對照(兩者都在最上面地圖的第 6 步「品質把關」,不是打包器本身的職責):

ESLint(靜態分析 Linting)=文法老師 Prettier(格式化 Formatting)=美妝師
關注 程式碼品質與潛在問題 程式碼外觀
具體管什麼 ① 語法錯誤 ② 不好的實踐(bad practice) ③ 潛在 bug ④ 未使用變數 縮排、換行、括號、分號、單雙引號
規則 高度可客製 極少、幾乎免設定
動作 抓出問題(可 --fix 部分自動修) 一鍵重排格式
在流水線 第 6 步「品質把關」 第 6 步「品質把關」

(ESLint 底層會先用一個 parser 把碼讀成 AST 再套規則檢查——那個 parser 常是 espreeacorn@typescript-eslint/parser,是第 6 步內部的實作零件,不是另一個獨立階段。這就是「提到 Acorn 時,它落在流水線第 6 步」。)

.eslintrc.json 是 ESLint 的設定檔,定義風格(分號、引號)、潛在錯誤規則、執行環境(瀏覽器 / Node)。rc = Run Commands,源自 Unix/Linux(如 .bashrc):程式啟動時自動讀取並執行此檔的設定。

4. React Developer Tools

瀏覽器擴充功能,像 React 網站的「X 光機」:

  • 偵測網站:若是 React 寫的,圖示會亮起。
  • 元件檢查器:以樹狀查看由哪些 React 元件組成,點任一元件可檢查 props 與 state(偵錯核心)。
  • Profiler 效能分析:記錄特定操作的渲染效能,找出渲染較差的元件來優化。

5. Parser「名牌」整理

JS/TS 四大名牌:

  • Acorn(橡實):JS 界最流行的輕量解析器,Webpack、Rollup、ESLint 底層預設都用它,快又小。
  • @babel/parser(原 Babylon):Babel 團隊維護,寫 JSX 或實驗階段語法一定用到,基於 Acorn 改寫。
  • Esprima:經典老牌,早期工具基石(早期 ESLint),教科書級。
  • SWC / Biome(Rust):現代為求極速用 Rust 重寫,SWC 為代表,Next.js 內部使用。

資料格式內建款: JSON.parse()(JS 內建 JSON 解析器);V8 / SpiderMonkey(瀏覽器內建 HTML/CSS Parser)。

解析器產生器(Parser Generator) — 給語法規則就自動生 Parser:ANTLR(跨語言頂級,常用於 SQL/搜尋指令)、Bison / Yacc(C/C++ 編譯器課祖師爺)。

[!tip] 你寫的 React 程式碼交給 Acorn / Babel 拆解;後端那條 SELECT … FROM group SQL 則常交給 ANTLR 寫出來的 SQL Parser 處理。

6. React 是怎麼被瀏覽器執行的?(JSX 轉譯 → 打包 → 執行)

瀏覽器只看得懂原生 JS,看不懂 React 的 JSX 語法(例如 <div>{count}</div>),
所以 React 專案在跑起來之前要經過:

  1. JSX 轉譯transpile3步驟(Babel / SWC):把 <h1 className="title">Hello</h1> 轉成 _jsx("h1", { className: "title", children: "Hello" }) 這種原生 JS 呼叫。
    JSX是React團隊設計的一套語法寫法,最後會轉譯成.js檔案,並非拆成HTML+CSS+JS檔案。
  2. 模組打包(Vite / Webpack / ESBuild):分析檔案依賴關係,把 .jsx/.js/CSS/圖片等整理、壓縮成瀏覽器看得懂的原生 JS。
  3. 瀏覽器執行:拿到打包後的原生 JS,走標準 JS 引擎流程(Scanner → Parser → AST → Bytecode/Machine Code)執行。

轉譯打包邏輯上是「先轉譯、再打包」,但實務上交織進行:打包工具從入口檔開始建立依賴圖,每讀到一個 .jsx 檔就呼叫轉譯器把它轉成標準 JS,全部轉完後再合併壓縮輸出成最終 bundle。

「JSX 明明變 JS,怎麼會有 HTML?每頁都變 HTML 嗎?SSG 怎麼跑?」

關鍵在:build 有沒有「多跑一步:執行那些 transpile 後的 JS、把結果印成 HTML 字串」

模式 JSX 的下場 build 產出 每頁有現成內容 HTML? DOM 何時建
CSR(Vite/CRA SPA) transpile 成 JS(_jsx(...)),build 不執行它 一份空殼 index.html + 一堆 JS ❌ 只有一份空殼 runtime:瀏覽器跑 JS、用 document.createElement 現建
SSG(Next output:export、Astro、Gatsby) 先 transpile 成 JS,build 時再「執行」那些 JS 每條路由各一份有內容的 .html + JS ✅ 每頁都有 build 時用 renderToStaticMarkuprenderToString 印成 HTML 字串;瀏覽器 parse 成 DOM 後 hydration
SSR(Next.js) 同上 transpile;每次請求在伺服器執行 JS 伺服器每請求現印一份有內容 HTML ✅(每請求現產) 伺服器印 HTML → 瀏覽器 parse → hydration
  • 「怎麼會有 HTML」:JSX→JS(transpile)一直都在;CSR 就停在這、只發 JS,DOM 由瀏覽器 runtime 現建(沒有現成內容 HTML)SSG/SSR 則多做一步「執行那些 JS」,用 renderToStringrenderToStaticMarkup 把 React 元件印成 HTML 字串——這才是 HTML 的來源。不是 JSX 直接變 HTML,是「執行 transpile 後的 JS」印出 HTML。
  • React 變 SSG 會怎麼跑:① build:transpile JSX→JS → ② build:Node 執行那些 JS,對每條路由 renderToStaticMarkup 產出 about.htmlposts/1.html… → ③ 部署成純靜態檔(純靜態 CDN 就能發) → ④ 瀏覽器拿到有內容的 .html、跑 HTML Parsing 成 DOM → ⑤ 載入 JS 做 hydration 掛互動。跟 CSR 只差第 ② 步:SSG 在 build 就把 HTML 印好了。(CSR/SSR/SSG 對照見 [[script載入方式+前因後果]] 與 [[SPA架構-入口點-CSR客戶端效能與狀態-部署]]。)
比較項目 原生 JavaScript React
語法 標準 JS,直接操作 DOM JSX(JS+HTML 混合),需轉譯
DOM 操作 直接操作真實 DOM(如 getElementById 虛擬 DOM,Diff 算法算出差異再統一更新
程式思維 命令式:一步步告訴瀏覽器怎麼做 聲明式:定義 State 與 UI 關係,畫面隨 State 自動更新
執行效能機制 每次改 DOM 立刻觸發重繪 記憶體中虛擬 DOM 比較,批次處理(Batching)更新

Scanner(詞法分析器 / Lexer) 是 JS 引擎編譯的第一階段,負責把原始碼字串拆成有意義的最小單位(Token)。例如 const count = 0; 會被拆成 const(關鍵字)、count(識別符)、=(指定運算子)、0(數字字面量)、;(分號)。JS 引擎處理流程大致是:Source Code → Scanner(字串→Tokens)→ Parser(Tokens→AST)→ Ignition 直譯器(AST→Bytecode 並執行)。

7. Node.js 在前端建置流程中的角色:只是「建置期工具」,不等於後端選型

常見誤解:以為專案用了 React 就代表後端一定要用 Node.js。實際上「前端開發/打包用的 Node.js」跟「後端伺服器用的 Node.js」是兩件完全獨立的事:

  • Node.js 本身不是編譯器,而是一個執行環境(Runtime),讓 Babel / Webpack / Vite / SWC 這些工具能在本機或 CI 環境讀寫檔案(fspath 等瀏覽器 JS 沒有的權限)並執行。
  • npm run build / npm run dev 時,其實是透過 Node.js 執行這些打包/轉譯工具;工具本身用 JS/C++/Rust/Go 寫成,在 Node.js 環境下把 JSX 原始碼轉譯打包成靜態 JS/CSS/HTML。
  • 打包完畢後 Node.js 的任務就結束了,產出的只是 dist/build/ 資料夾裡的靜態檔案。

即使後端選型是 Python(Django/FastAPI)、Go、Java 等非 Node 語言,本機或 CI/CD 環境開發 React 前端時,依然需要安裝 Node.js 來跑打包工具——這跟後端用什麼語言完全不衝突。部署階段常見架構:

架構 說明 線上 Server 需要 Node.js 嗎?
前後端完全分離 前端靜態檔放 Nginx/S3+CDN/Vercel;後端 Python API 獨立跑在別的 Server 不需要
Python 後端直接託管靜態檔 打包出的 index.html/JS/CSS 放進 Python 的 static/,由 Flask/Django 路由回傳 不需要
SSR(如 Next.js) 需要即時在伺服器用 JS 渲染 HTML 需要(但後端是 Python 時通常不會選這種架構)

一句話總結:後端選 Python,代表 API Server(Production Runtime)是 Python;前端打包用 Node.js,代表編譯工具(Development Build Tool)借用了 Node.js 環境——兩者是不同層次的分工,不衝突。

延伸:Babel/tsc 轉譯(本篇第 1、5、6 節)跟 V8 引擎本身的 Parse→Ignition→TurboFan 編譯管線是兩個不同層次、不同時間點的事,完整對照與 4 張「原生 JS vs React × 冷/熱路徑」流程圖見 [[04-V8引擎完整管線-Parse到Deoptimization-【編譯runtime】|04-V8引擎完整管線-Parse到Deoptimization(編譯 runtime)]]。

各對話來源

前端開發工具筆記整理 (2026-06) — https://gemini.google.com/app/60eb0e34d9fc653b

使用者: (貼上自己關於 Parser / Compiler / 打包工具 / React DevTools / ESLint vs Prettier 的零散筆記) 你覺得我這些筆記要怎麼整理比較好?

Gemini: 建議分四個區塊有系統地整理:
① 編譯與解析核心(Parser=讀懂拆解、Compiler=翻譯,第一步用 Parser);
② 深入打包工具(為何需要、編譯/壓縮/打包三任務、Webpack vs Turbopack);
③ 開發工具功用(React DevTools 像 X 光機:元件檢查器看 props/state、Profiler 分析渲染效能);
④ 程式碼品質工具區別(ESLint 文法老師管品質、Prettier 美妝師管格式)。一句話故事:Parser 讀懂 → Compiler 翻譯 → ESLint/Prettier 把關 → 打包工具上線優化 → DevTools 偵錯。

使用者: .eslintrc.json 是什麼樣的文件?rc 代表什麼?

Gemini: 它是 ESLint 的設定檔,定義風格(分號、單雙引號)、潛在錯誤(禁未使用變數)、執行環境(瀏覽器/Node)。rc = Run Commands(執行指令),源自 Unix/Linux(如 .bashrc):程式啟動時自動讀取執行此檔設定。

使用者: 解析器有哪些「牌子」?

Gemini: 技術上稱套件/工具/引擎。JS/TS 四大:Acorn(最流行輕量,Webpack/Rollup/ESLint 底層)、@babel/parser(JSX/實驗語法,基於 Acorn)、Esprima(經典老牌)、SWC/Biome(Rust 重寫求速,Next.js 用 SWC)。資料格式:JSON.parse()、瀏覽器內建 V8/SpiderMonkey。解析器產生器:ANTLR(跨語言頂級,SQL 常用)、Bison/Yacc(C/C++ 祖師爺)。

JavaScript 編譯與優化解析(2026-07-27)— https://gemini.google.com/app/0211c80a1b2bcc92

使用者:React 是怎麼去編譯的呢他跟原生 JS 差在哪 / 所以先轉譯才打包囉?Scanner 是啥 / 好像聽 YouTube 影片說是由 nodejs 編譯的欸還是我記錯了 / 但我一般在打包前端的時候我不一定會用到 nodejs 啊,萬一我的後端選型不是 nodejs 而是 Python 呢?

Gemini:說明 React 需經 JSX 轉譯(Babel/SWC)與模組打包(Vite/Webpack/ESBuild)才能被瀏覽器執行,並比較 React 與原生 JS 在 DOM 操作、程式思維、效能機制上的差異;解釋 Scanner 是 JS 引擎編譯第一階段的詞法分析器,把原始碼拆成 Token;澄清 Node.js 不是編譯器本身而是執行環境,讓 Babel/Webpack 等工具能讀寫檔案並執行;最後說明前端打包用 Node.js(開發期工具)與後端選型用 Python(Production Runtime)是兩個不衝突的獨立分工,並列出前後端分離/Python 託管靜態檔/SSR 三種常見部署架構。

0. 四步流水線(依順序編號)

網路層 HTTP response body
   ↓
1. input byte stream(位元組流)      ← 網路層,還不是文字
   ↓
2. decoding(解碼)→ input stream     ┐
   ↓                                  │
3. tokenization(標記化)→ tokens     ├─ 這三步合起來 = HTML Parsing
   ↓                                  │
4. tree construction(樹建構)        ┘
   ↓
DOM 樹(產物,不是第 5 步)
  • (a) HTML Parsing 的範圍是步驟 2、3、4。WHATWG 規範第 13.2 章「Parsing HTML documents」涵蓋的正是這三段。
  • (b) 步驟 1 屬於網路層,DOM 是**名詞(產物)**不是動詞(步驟)。
  • (c) 完整可互動的 SVG 主軸圖在同名 .html;後續追問請指回圖上的第幾步,不要另開新章節。

1. 步驟 1 — bytes

  • (d) byte(位元組)= 8 個 bit。網路上、磁碟上一切都是 bytes,沒有例外;
  • 「文字」是解碼之後才存在的概念
  • (e) 「HTML 是純文字格式」講的是它的內容語意(相對於 JPG/MP4 那種二進位格式),不是說它傳輸時不是 bytes。
說法 對錯 精確講法
HTML 是純文字格式 指內容語意,不是儲存或傳輸形式
HTML 傳輸時不是 bytes 一定是 bytes
拿到 response body 就是字串了 拿到的是 byte stream,還要走步驟 2

<!DOCTYPE html> 在網路上的真面目:

字元:  <   !   D   O   C   T   Y   P   E  (空白) h   t   m   l   >
bytes: 3C  21  44  4F  43  54  59  50  45  20  68  74  6D  6C  3E
  • (f) F12 驗證:Network → 點 HTML → Headers 的 Content-Length: 1834,這個數字的單位是 bytes 不是字數。中文一字在 UTF-8 佔 3 bytes,所以中文網頁的 bytes 數遠大於字數。

2. 步驟 2 — decoding,以及那個雞生蛋問題

2-1. 產物叫 input stream,不叫字串

比較項 字串 String 流 Stream
長度 .length,一開始就知道 不知道,可能還在下載
存取 可隨機索引、可回頭 只能依序消耗,讀過就過去
中途變長 不行 可以(網路 chunk、document.write()
存在哪 完整存在記憶體 邊來邊處理
  • (g) 規範用詞是 input stream,內容是 code point(碼點),不是「字串」也不是「字元陣列」。
  • (h) 這個差別就是「HTML 可以邊下載邊顯示」的原理;串流式 SSR(React 18 renderToPipeableStream)能分段送 HTML,也是吃這個性質。

2-2. encoding sniffing algorithm

<meta charset> 寫在 bytes 裡面,要讀它得先解碼,要解碼得先知道 charset —— 規範的解法是這個優先序:

順序 依據 說明
1 BOM(Byte Order Mark) UTF-8 是 EF BB BF。有就直接採信
2 使用者手動指定 瀏覽器選單強制指定
3 HTTP header Content-Type: text/html; charset=utf-8優先於 meta
4 prescan 前 1024 bytes <meta charset>這就是它必須放 head 最前面的原因
5 父文件 iframe 同源時繼承
6 歷史紀錄 之前造訪偵測到的
7 自動偵測 統計特徵猜(規範不鼓勵)
8 語系預設 猜錯就是亂碼 mojibake,「中文」變 中文
  • (i) 「中」在 UTF-8 是 E4 B8 AD;誤用 Latin-1 解讀會把 3 個 bytes 各當一個字 → 中bytes 沒錯,錯的是那張解碼表。
  • (j) 這一段與 [[HTML文件結構-DOCTYPE與骨架]] 的 Q9、Q10 是同一件事的兩個切面:那篇答「要放哪裡」,本篇答「在流水線的第幾步、為什麼非有不可」。

3. 步驟 3 — tokenization(HTML 也有)

語言 誰執行 tokenize token 種類 下一步產出
HTML Blink(渲染引擎) DOCTYPE / start tag / end tag / comment / character / EOF,共 6 種 DOM 樹
CSS Blink 的 CSS parser ident / hash / string / dimension / delim … CSSOM 樹
JavaScript V8(JS 引擎) keyword / identifier / operator / literal / punctuator … AST → bytecode
  • (k) tokenization 是編譯原理的通用術語,任何要被機器讀懂的語言都有這一步,不是 JS 專利。
  • (l) ⚠️ HTML 永遠不會進 V8、不會變成 AST 或 bytecode。三條線都在同一條主執行緒上用 CPU,但是三個不同元件。(與 [[script載入方式+前因後果]] (q) 節一致)
  • (m) tokenizer 自己也是一台狀態機,狀態約 80 個(data statetag open stateattribute name state…),只管切字元、不管樹長什麼樣

<div id="root">Hi</div> 的 token 序列:

① StartTagToken  { name: "div", attributes: { id: "root" } }
② CharacterToken { data: "H" }
③ CharacterToken { data: "i" }
④ EndTagToken    { name: "div" }
  • (n) token 還是「資料」不是「物件」,沒有 .style、沒有 .addEventListener

3-b. 反過來問:轉譯之後才能做 HTML Parsing 嗎?

不是。兩者沒有任何依賴關係。

會有這個錯覺,是因為建置流程圖通常由上往下畫,看起來像一條線。但那張圖跨了兩台機器、兩個時間點,中間是斷開的:

【機器 A:你的筆電 / CI】          build-time,做一次
  .tsx → 轉譯 → bundle → 產出靜態檔
                    │
                    ▼
              ✂️ 部署,斷開 ✂️(中間可能隔了三天)
                    │
                    ▼
【機器 B:使用者的瀏覽器】          run-time,每人各做一次
  收到 bytes → decode → tokenize → tree construction → DOM
  • (t) 兩個直接反證:① 純手寫的 .html 完全沒有轉譯,瀏覽器照樣 parse;② PHP、WordPress、Rails 前端根本沒有轉譯步驟,HTML 是後端 template 吐的字串,直接進瀏覽器。→ 轉譯不是 HTML Parsing 的前置條件。
比較項 轉譯 transpile HTML Parsing
處理對象 JS / TS / JSX HTML 字串
何時 build-time run-time
誰的 CPU 開發者/CI,做一次 每一個使用者,各做一次
執行者 Babel / SWC / esbuild Blink 的 HTML parser
產物 還是純文字 JS DOM 物件

build tool 對 index.html 只做文字層級處理(注入 <script src><link> 標籤 + minify),那不叫轉譯,那只是字串替換。與 [[script載入方式+前因後果]] (q) 節一致:「build 期間沒有瀏覽器、沒有 DOM,所以沒有『HTML Parsing → DOM 樹』那個過程。」

混淆的真正來源:JSX 的 <div> 長得像 HTML

  • (u) ★ JSX 的 <div> 與 HTML 的 <div> 是完全不同的兩個東西,走兩條互不相交的管線。
JSX 裡的 <div> index.html 裡的 <div>
本質 JavaScript 語法擴充 HTML 標籤
誰處理 Babel / SWC(build-time) Blink 的 HTML parser(run-time)
處理後變成 _jsx("div", {...}) 純 JS 函式呼叫 HTMLDivElement 物件
會進 HTML parser 嗎 永遠不會 ✅ 會
會進 V8 嗎 ✅ 會(轉譯後就是 JS) 永遠不會
// 你寫的
<div className="a">Hi</div>

// 轉譯後(這已經是純 JS,沒有任何 HTML)
_jsx("div", { className: "a", children: "Hi" })

// 瀏覽器執行它時,React 呼叫的是
document.createElement("div")   // ← 直接建物件,繞過 HTML parser

React 建 DOM 靠的是 document.createElement,不是 HTML parser。 兩條線平行,不交會。

run-time 的真實順序:反而是 HTML Parsing 先

1. HTML Parsing 開始(步驟 3、4 進行中)
        ↓
2. 讀到 <script src="main.abc.js">    ← 這個 tag 是 build 時被注入的
        ↓
3. HTML Parsing 停住(無屬性)或繼續(defer)
        ↓
4. V8 執行那支 JS —— 它「早就」轉譯完了,在三天前的 CI 上
        ↓
5. React 跑起來,用 createElement 往 #root 塞東西

⚠️ 在使用者的機器上,轉譯這件事早就結束了,不參與任何順序

  • (v) 唯一的關聯是間接的:轉譯 → bundle → 產出 main.abc123.js → build tool 把 <script src="main.abc123.js"> 注入 index.html 的文字裡。所以轉譯影響的是「HTML 裡那個 src 字串寫什麼」,不是「HTML 能不能被 parse」。就算 src 指向不存在的檔案,HTML Parsing 照樣跑完,只是那支 script 404。

唯一一條「JSX 的 div 最後真的被 HTML parse」的路徑:SSR

轉譯(build-time)→ 部署 → 使用者請求
   → Node 上執行 renderToString()    ← ★ 執行,不是轉譯
   → 產出 HTML 字串 "<div class='a'>Hi</div>"
   → HTTP 傳輸 → 瀏覽器 HTML Parsing(本篇步驟 2、3、4)

中間那一步是「執行」不是「轉譯」。轉譯早在 build 就做完了,SSR 是在 run-time(伺服器端的 run-time)那份已經轉譯好的 JS。

一句話總結:轉譯是 build-time 的翻譯工作,HTML Parsing 是 run-time 的建構工作,對象不同、機器不同、時間不同,沒有先後依賴。


4. 步驟 4 — tree construction 與 insertion mode

  • (o) "initial" "before html" "in head" "in body"insertion mode(插入模式)。它不是 HTML 標籤、不是任何程式語言的語法,而是 WHATWG 規範文件裡的英文術語,代表樹建構狀態機的狀態。瀏覽器實作時變成 enum,例如 Blink 的 kInHeadMode

規範共 21 個:

initial            before html        before head        in head
in head noscript   after head         in body            text
in table           in table text      in caption         in column group
in table body      in row             in cell            in template
after body         in frameset        after frameset     after after body
after after frameset
  • (p) 為什麼要分這麼多狀態:同一個 token 在不同位置意義不同。<td>in row 是合法儲存格,在 in body 就是錯放、規範明文要求忽略它。HTML「寫錯也不會白屏」正是因為每個 insertion mode 都寫好了補救規則 —— 這是與 XML 最大的差別。

5. DOM 節點是「真正的 JS 物件」嗎 ★★★★★

5-1. 它是宿主物件,不是用 JS 寫的

層次 真相
你拿到的 document.body ✅ 是 JS 物件,有原型鏈、可 . 取屬性
資料實際住在哪 ❌ 不在 JS 堆積。真正的節點是引擎的 C++ 物件(Chrome 是 Blink)
JS 拿到的是什麼 透過 WebIDL binding 產生的包裝物件(platform object/宿主物件)

驗證:Object.keys(document.body) 回傳 []document.body.hasOwnProperty('tagName')false —— 屬性全是 prototype 上的 getter,背後轉呼叫 C++。

  • (q) 實務價值:每次讀寫 DOM 屬性都是一次 JS ↔ C++ 跨界呼叫,比操作純 JS 物件貴得多。這就是虛擬 DOM 存在的理由(先在便宜的純 JS 物件上算差異,最後才一次跨界),也是 layout thrashing 的成因。

5-2. extends 是誰繼承誰

class 子 extends 父。左邊是子類,右邊是父類。

寫法 子(繼承者) 父(被繼承者) 白話
HTMLDivElement extends HTMLElement HTMLDivElement HTMLElement div 是一種 HTML 元素
HTMLElement extends Element HTMLElement Element HTML 元素是一種元素(SVG 元素也是)
Element extends Node Element Node 元素是一種節點(文字、註解節點也是)
Node extends EventTarget Node EventTarget 節點是一種可接收事件的東西(window、XHR 也是)

三個記法:

  • (r-1) is-a 造句法A extends B 讀成「A is a B」。「div is a HTMLElement」通順 ✅,反過來不通 ❌。

  • (r-2) 範圍大小法父的範圍一定比子大。EventTarget > Node > Element > HTMLElement > HTMLDivElement。

  • (r-3) 查找方向法:JS 找屬性是從子往父往上找。在 div 上呼叫 addEventListener,一路找到 EventTarget.prototype 才找到。

  • (s) ★★★★★ addEventListener / removeEventListener / dispatchEvent 三個方法只定義在 EventTarget 這一層styleHTMLElementclassListElementappendChildNode。所以「可設寬高、可掛事件」是繼承來的能力,字串形態的 <div> 沒有這條鏈,什麼都做不了。

⚠️ 瀏覽器不是真的用 class ... extends ... 這段 JS 寫出來的;規範用的是 WebIDL 的 interface HTMLDivElement : HTMLElement,冒號左邊是子、右邊是父,與 extends 同義。


6. CSR / SSR / SSG / PHP / WordPress / Rails 有差嗎

四步完全一樣,零差別。 差別只在三個變數:誰產生 HTML 字串、什麼時候產生、第一個 response 裡有沒有內容。

模式 HTML 字串誰產生 何時 首個 response 有內容 四步 四步後還要做什麼
純靜態 HTML 人手寫 寫程式時 相同 直接 paint
SSG(Astro / Hugo / Jekyll / next export) 建置工具 build time 一次 相同 有互動則需 hydration
SSR(Next.js / Nuxt / Remix) Node 跑 renderToString 每次 request 相同 hydration 掛事件
PHP / WordPress PHP 直譯器 每次 request 相同 沒事了
Rails / Django / Laravel template 引擎(ERB / Jinja / Blade) 每次 request 相同 沒事了
CSR(Vite + React SPA) 瀏覽器裡的 JS HTML 解析完、JS 執行時 #root 是空的 相同 還要跑一整套 JS 才有內容
Streaming SSR(React 18) Node 分段吐 每次 request,邊算邊送 ✅(陸續到) 相同 分段 hydration
  • 原本 [[script載入方式+前因後果]] (a) 節寫的「不是 CSR、SSR、SSG⋯⋯的專屬,他們全一樣,無例外」是對的,可再精確成:

    HTML Parsing 這四步無例外,但四步結束後畫面上有沒有東西,就完全不一樣了。

  • CSR 的 HTML 解析反而更快(HTML 極短、節點極少)。它慢在四步跑完是白畫面,還要等 JS 下載 → V8 parse → 執行 → createElement 再建一次 DOM。瓶頸在 JS 不在 HTML Parsing。

一分鐘驗證法:Ctrl+U(伺服器原始字串)對比 F12 → Elements(現在的 DOM)。差很多且 #root 空 → CSR;幾乎一樣 → SSR/SSG/PHP/Rails。


7. Markdown → DOM 的兩條路

路徑 A:marked/markdown-it + innerHTML(要跑 HTML Parsing)

.md bytes → decode → Markdown 字串
  → Markdown 自己的 tokenizer → mdast
  → renderer 印成 ★HTML 字串★
  → el.innerHTML = htmlString
  → ★觸發 fragment parsing algorithm★(=本篇步驟 3、4 的片段版)
  → DOM

tokenize 兩次(Markdown 一次、HTML 一次)。

路徑 B:react-markdown(remark + rehype-react)—— 不經過 HTML 字串

.md bytes → decode → Markdown 字串
  → remark → mdast → remark-rehype → hast(仍是 JS 物件)
  → rehype-react → React element
  → React 呼叫 document.createElement / appendChild
  → DOM

❌ 全程沒有 HTML 字串 ❌ 沒有 HTML Parsing
比較 路徑 A 路徑 B
中間有 HTML 字串 沒有
跑 HTML Parsing 有(fragment parsing) 沒有
tokenize 次數 2 1
XSS 風險 高,要接 DOMPurify 低,天生擋掉大部分注入
建 DOM 的人 Blink 的 HTML parser React 呼叫 createElement

⚠️ 要回頭修正 [[Markdown-渲染為DOM的過程]]:那篇的「Markdown 不能直接被渲染成 DOM,一定要先轉成 HTML 字串」對路徑 A 成立、對路徑 B 不成立。路徑 B 繞過 HTML 字串直接建 DOM,這正是 react-markdown 比 dangerouslySetInnerHTML 安全的原因 —— 沒有字串可以被注入


相關筆記(含關聯理由)

  • [[HTML文件結構-DOCTYPE與骨架]](同資料夾)
    理由:那篇講「骨架每一行要怎麼寫」,本篇講「這些字元怎麼被讀成物件」。它的 Q9、Q10(charset 放最前面、不寫會亂碼)就是本篇 §2-2 的結論。
  • [[script載入方式+前因後果]]
    理由:那篇 (a) 節開頭那句「收到的是純文字字串」正是本篇整篇要展開的內容;那篇接著講 <script> 如何打斷本篇的步驟 3、4。本篇是它的前傳。
  • [[Markdown-渲染為DOM的過程]]
    理由:本篇 §7 補上那篇缺的路徑 B,並指出「一定要先轉成 HTML 字串」的例外。
  • [[SPA架構-入口點-CSR客戶端效能與狀態-部署]]
    理由:本篇 §6 的表格是那篇的上游 —— 先懂解析流程一致,才懂 CSR 的慢不在解析。
  • [[00-V8引擎完整管線-Parse到Deoptimization]]
    理由:本篇 §3 說「HTML 永遠不會進 V8」,那篇是 V8 那條平行線的完整管線。
  • [[Critical-Rendering-Path-關鍵渲染路徑-重排vs重繪]]
    理由:本篇止於 DOM 產出,那篇從 DOM + CSSOM 接手講到 Layout / Paint / Composite。

線上版(GitHub Pages,Obsidian 之外的人可以直接看):
https://abbychickenfillet-github.github.io/golang-and-TS-others-md-files/frontend-docs/html-basics/HTML-Parsing-瀏覽器拿到HTTP-response-body之後.html


資料來源(含查證時間)

主題 連結 版本/時間
解析總章、tokenization 與 tree construction 的切分 https://html.spec.whatwg.org/multipage/parsing.html Living Standard,2026-09-08 查證
encoding sniffing algorithm 的八個優先序 https://html.spec.whatwg.org/multipage/parsing.html#determining-the-character-encoding Living Standard,2026-09-08 查證
21 個 insertion mode 完整清單 https://html.spec.whatwg.org/multipage/parsing.html#the-insertion-mode Living Standard,2026-09-08 查證
input stream 由 code points 構成 https://html.spec.whatwg.org/multipage/parsing.html#preprocessing-the-input-stream Living Standard,2026-09-08 查證
EventTarget 的三個方法 https://developer.mozilla.org/en-US/docs/Web/API/EventTarget 2026-09-08 查證
Node / Element / HTMLElement 的能力分層 https://developer.mozilla.org/en-US/docs/Web/API/Node 2026-09-08 查證
DOM 是 platform object、透過 WebIDL 暴露給 JS https://webidl.spec.whatwg.org/#idl-interfaces Living Standard,2026-09-08 查證
speculative parsing https://developer.mozilla.org/en-US/docs/Glossary/Speculative_parsing 2026-09-08 查證
DOMParser https://developer.mozilla.org/en-US/docs/Web/API/DOMParser 2026-09-08 查證

【編譯 runtime】V8 引擎完整管線:Parse → Ignition → TurboFan → Deoptimization

[!info]- 📍 00號:整個編號序列的起點
起點:這是JS_Core_and_Runtime資料夾照編譯到執行順序編號的第一篇,畫出Parse→Ignition→TurboFan→Deoptimization整條管線地圖。
下一步:管線圖裡第一個要拆解的詞就是「引擎」本身,下一篇[[01-引擎-Engine-到底是什麼]]先把這個詞定義清楚。

比 [[機器碼與bytecode的差異]] 那篇「6. V8 的實際管線」更詳細的版本,把 Scanner/Scope Analysis、Profiling、Escape Analysis、Inline Caching、Type Specialization、Deoptimization 全部串起來。

V8 是 Chrome 的 JS 引擎嗎?跟 SSR 有關係嗎?

「V8 是 Chrome 的 JS 引擎」這句話對,但不完整——V8 最早是為 Chrome 開發的沒錯,但它是一個獨立、可嵌入任何 C++ 專案的引擎,不是只綁死在 Chrome 瀏覽器裡:

  • Chrome / Edge / Brave 等 Chromium 系瀏覽器:直接內嵌 V8。
  • Node.js:把 V8 抽出來,外面包 libuv(處理檔案 I/O、網路等的事件迴圈)與 Node 專屬 API(fshttp…),讓 JS 可以離開瀏覽器、在伺服器上跑。「只包一層 libuv」是簡化說法,完整架構(libuv 具體是什麼、Node 還有哪些層、CSR 渲染邏輯在哪執行、純前端會不會用到 Node API)另開一篇:[[Node-js底層架構-V8-libuv-Bindings與CSR澄清]]。
  • Deno、Electron:也是內嵌 V8(Electron 甚至同時內嵌 V8 + Chromium)。

跟 SSR(Server-Side Rendering)的關係:Next.js、Nuxt 這類 SSR 框架,是讓 React/Vue 的渲染邏輯改在伺服器上的 Node.js執行,而 Node.js 底層就是 V8。也就是說,SSR 時伺服器產生 HTML 字串的那段 JS,走的正是本篇這一整套 Parse → Ignition → TurboFan → Deoptimization 管線,只是少了瀏覽器提供的 window/document(改用 Node 的 host 環境),並不是換了一顆完全不同的引擎。差異只在執行環境(host environment:瀏覽器 Web APIs vs Node.js APIs),不是引擎本身或編譯管線。

Node.js 在一般前端「打包」流程裡的角色(跟 SSR 不是同一件事,容易搞混)另有詳細說明,見 [[前端開發工具-打包編譯Lint與Parser]] 第 7 節。

完整流程圖【編譯 runtime,不是打包 buildtime】

[!warning]+ 這張圖到底在講哪個時間點?——全程都是「編譯 runtime(執行期)」
a. 下圖從「JavaScript 原始碼」一路到 Deoptimization 的每一站,都發生在瀏覽器/Node.js 載入並執行這支腳本的當下,由 V8 引擎自己完成。這裡的「編譯」指的是 JIT(Just-In-Time Compilation,即時編譯)——名字裡的 Just-In-Time 就是「執行到的那一刻才編譯」的意思。
b. 打包 buildtime(建置期)發生在這張圖的左邊界之外:Babel/tsc/SWC 把 TSX/JSX 轉成標準 JS、bundler(webpack/Vite/Rollup/Turbopack)把多個模組合併壓縮,這些都是在你的電腦或 CI 上跑完、部署之前就結束的事。V8 拿到的永遠是「已經轉譯打包完的標準 JS」,它看不到你原本寫的 TSX。
c. 用詞請跟著本資料夾的約定走:buildtime 那一段一律叫「轉譯(transpile)」與「打包(bundle)」,不要叫「編譯」;「編譯(compile)」這個詞只留給 V8 在執行期做的 AST → Bytecode → 機器碼。理由是方向不同——轉譯是高階語言 → 另一個同樣高階的語言(TSX → 標準 JS),編譯是高階 → 更低階的表示法。這組用詞的完整分辨與工具對照,主場在 [[03-前端開發工具-打包轉譯Lint與Parser-【打包buildtime】|03-前端開發工具(打包 buildtime)]],本篇只放結論。
d. 想看 buildtime 那一段的完整流程,請改讀同資料夾的 [[03-前端開發工具-打包轉譯Lint與Parser-【打包buildtime】|03-前端開發工具(打包 buildtime)]](那篇開頭有〈buildtime 全流程地圖〉表格,逐步對應到各篇);至於這些步驟是誰觸發、依什麼順序跑,主場在 [[08-npm-run-script-mechanism]] 與 [[09-npm-scripts-pre-post-生命週期鉤子]]——npm run build 才是把所有 buildtime 工具串起來的那根線。打包工具選型另見 [[07-前端專案建立與打包選型-Vite與createVue與NextJS與npm鎖版本]]。

flowchart TD
    A["JavaScript 原始碼"] --> B
    subgraph Parse["Parse(解析)"]
        B["Scanner 詞法分析<br/>→ Tokens"] --> C["Parser 語法分析<br/>→ 建立 AST"]
        C --> D2{"Early Error 靜態語法檢查<br/>例:重複的參數名稱、重複的 let/const 宣告"}
        D2 -- 檢查沒過 --> D2X["直接 SyntaxError<br/>連 Bytecode 都不會生成"]
        D2 -- 檢查通過 --> D["Scope Analysis 範疇分析<br/>初步判定:這個變數有沒有被閉包捕獲?<br/>→ 決定放 Stack/暫存器(快)還是 Context 物件(Heap,慢)"]
    end
    D --> E
    subgraph IgnitionBox["Ignition(直譯器)"]
        E["把 AST 編成 Bytecode<br/>並立即直譯執行"] --> F["收集 Profiling Data<br/>(Feedback Vector:傳入型別、呼叫次數…)"]
    end
    F --> G{"判定為 Hot Code?"}
    G -- 否,繼續用 Ignition 直譯 --> E
    G -- 是 --> H
    subgraph TurboFanBox["TurboFan(JIT 最佳化編譯器)"]
        H["利用 Profiling Data 做高階優化"] --> H1["Escape Analysis<br/>物件標量替換/棧分配優化"]
        H --> H2["Inline Caching<br/>內聯快取"]
        H --> H3["Type Specialization<br/>型別特化"]
        H1 --> I["生成高度優化的 Machine Code"]
        H2 --> I
        H3 --> I
    end
    I --> J["執行 Machine Code(極快)"]
    J -- "型別突然改變<br/>(例如本來都傳 number 突然傳 string)" --> K["Deoptimization 去優化<br/>放棄 Machine Code,退回 Ignition 繼續跑 Bytecode"]
    K --> E

上圖 Parse 裡新增的 Early Error 節點,例子(重複參數名稱何時合法、何時直接 SyntaxError)見 [[函式呼叫核心機制-Execution-Context-與-Parameter-Binding]] 的 (e) 節——這類錯誤在 Parse 階段就會被抓出來,根本不會走到 Ignition 生成 Bytecode。

各階段名詞解釋

Parse 階段

  • Scanner(詞法分析/Lexer/Tokenizer):三個詞可以直接畫等號——Scanner=Lexer=Tokenizer,業界混用,指同一件事:把原始碼文字逐字掃過,切成一顆顆有意義的最小單位——Token(也叫 Lexeme/語素;關鍵字、identifier、運算子、字面量…)。這一步只管「切詞」,不管文法對不對。
  • Parser(語法分析):把 Token 序列按 JS 文法規則組成樹狀結構 AST,同時檢查語法對不對(少個括號這種錯誤在這裡就會被抓到)。遇到解構參數({a,b}[a,b])這種寫法時,Parser 具體依據的正是 ECMA-262 Destructuring Binding Patterns 這份規格條文——關係:規格文字定義「合法的解構模式長怎樣」,Parser 就是把這份規格條文轉成程式邏輯的那個實作;重要性:Scanner 產生的 Token 是 Lexical Grammar 的產物(見 [[字面量-關鍵字-識別碼基礎]]),Parser 再依這份 Syntactic Grammar 規則把 Token 組合成樹——兩份文件合起來,正好對應 Parse 階段「先切詞、再組句」的兩個步驟。
  • Scope Analysis(範疇分析):在建好 AST 後,V8 對每個 scope 做的事不只「判斷閉包捕獲」一項,而是把整個靜態範疇結構都定下來:
    • 解析變數的歸屬(scope chain 解析):把 AST 裡每一個識別碼的「使用」都對應回它到底是哪個 scope 宣告的(或者哪個都不是、屬於全域/未宣告),建立完整的範疇鏈——這是所有後續判斷的地基。
    • 判斷閉包捕獲,決定放 Stack 還是 Heap:這個變數有沒有被內層函式(閉包)捕獲?沒被捕獲 → 留在快速的 Stack/暫存器(函式執行完直接釋放,效能好,見 [[return-清理記憶體-stack-frame與閉包例外]]);有被捕獲 → 放進堆積(Heap)上的 Context 物件,讓閉包長期抓著它。這是初步/必要的判定(決定正確性,不是可有可無的優化),只看語法結構——「AST 上有沒有內層函式引用這個變數」,不看實際執行狀況。
      • 這個「初步判定」跟下面 TurboFan(圖裡 H1 節點)做的 Escape Analysis(逃逸分析)+物件標量替換(Scalar Replacement) 是兩個不同層次的機制,容易被搞混,對照如下:

        Scope Analysis 的初步判定(這裡) TurboFan 的 Escape Analysis(下方 TurboFan 階段)

        | 發生時機 | Parse 階段,每個函式都會做一次 | 只有被判定為 Hot Code、送進 TurboFan 之後才會做 |
        | 判斷依據 | 靜態語法結構:AST 上有沒有內層函式引用這個變數 | 實際執行 Profile:這個物件在真正跑過的案例裡,有沒有被回傳、存到外部變數、被閉包捕獲 |
        | 保守程度 | 保守——只要「可能」被捕獲就先放 Heap,確保正確性優先 | 激進——只要「證明」完全不會逃逸,可以直接連 Heap 都不配置 |
        | 對物件的處理 | 二選一:整包放 Stack 或整包放 Heap Context | 可以更細:把物件拆成好幾個獨立的純量值(scalar),例如物件的每個欄位各自變成一個暫存器變數,完全跳過「配置一整個物件」這件事 |
        | 目的 | 決定正確性(閉包捕獲的變數絕對不能被提早釋放) | 追求極致效能(能不進 Heap 就不進,省下配置與 GC 成本) |

        一句話:Scope Analysis 是「看語法就能做的保守判斷」,Escape Analysis 是「看實際執行狀況才敢做的激進優化」——前者是每次都跑的必要步驟,後者是熱點程式碼才有的加碼優化。

    • var/function Hoisting 與 let/const 的 TDZ 邊界:確定 var 要被提升到哪個最近的函式 scope、function 宣告要不要整個提升,以及 let/const 的暫時性死區(TDZ)範圍從哪裡到哪裡。
    • 偵測 eval / with:如果這個 scope 裡出現直接 eval() 呼叫或 with 語句,因為它們可以在執行期動態新增/改變綁定,會破壞靜態分析的前提,V8 必須把整個 scope 標記成「不可靜態優化」,退回保守、較慢的處理方式。
    • 判斷要不要建立 arguments 物件:如果函式本體根本沒引用 arguments,V8 可以直接省略建立它,省一筆開銷(哪種參數列表會影響 arguments 是「mapped」還是「unmapped」,見 [[函式呼叫核心機制-Execution-Context-與-Parameter-Binding]] 的簡單參數列表說明)。
    • strict mode 判定:從 "use strict" 指令或 ES Module 環境推定這段程式碼是不是 strict mode,會影響後面一系列語法限制。
    • Early Error 靜態語法檢查:例如同一個參數列表裡重複的參數名稱、同一 scope 裡重複的 let/const 宣告,這類「編譯期就能確定是錯的」語法錯誤,也是在這個階段被抓出來(完整的重複參數名稱規則見 [[函式呼叫核心機制-Execution-Context-與-Parameter-Binding]] 的 (e))。

Ignition(直譯器)階段

  • 把 AST 編譯成精簡的 Bytecode,然後直譯執行(見 [[機器碼與bytecode的差異]] 對 bytecode 概念的完整解釋)。
  • 執行的同時順手收集 Profiling Data(V8 內部叫 Feedback Vector):記錄「這個函式被呼叫幾次」「傳進來的參數通常是什麼型別」「這個屬性存取通常是對哪種物件形狀(Shape/Hidden Class)」等統計資料,供之後 TurboFan 判斷要不要優化、怎麼優化。

Hot Code 判定

V8 監控函式的呼叫次數(以及迴圈的執行次數,這種情況叫 OSR/On-Stack Replacement),超過門檻就標記成「熱點程式碼」,送去給 TurboFan 優化。沒達標的程式碼就繼續留在 Ignition 直譯執行——多數程式碼其實只跑一兩次,直接省下 TurboFan 的編譯成本。

[!question]- 「超過一次呼叫」就算 Hot Code 嗎?——不是
門檻不是「呼叫次數 > 1」這種離散計數器,而是一個額度(interrupt budget):Ignition 執行時依「跑了多少 bytecode/迴圈 back-edge 次數」持續扣掉這個額度,扣到 0 才觸發「該不該送去優化」的判斷——扣額度看的是累積執行量,不是單純數「第幾次呼叫」。也因此:一個函式只呼叫一次、但內部跑超大迴圈,一樣可能透過 OSR 變熱(見下面「實例:拿掉 return 的無窮迴圈」,就是只呼叫一次卻觸發 OSR 的案例);反過來,呼叫兩三次但函式體很小,額度通常扣不完,還是留在 Ignition。

另外現代 V8 其實是多層 tiering,不是只有本篇簡化畫的 Ignition/TurboFan 兩層:Ignition(直譯)→ Sparkplug(baseline JIT,門檻很低,幾乎跑沒幾次就會編,但不做激進優化)→ Maglev(中階優化)→ TurboFan(最高階,門檻最高,要累積夠多 Profiling Data 才划算送進去)。門檻高低跟編譯成本成正比:越激進的優化器,門檻設越高。

TurboFan(JIT 優化編譯器)階段

利用 Ignition 收集到的 Profiling Data,針對這個熱點函式實際觀察到的情況做高度客製化的優化:

  • Escape Analysis(逃逸分析)+ 物件標量替換(Scalar Replacement):分析一個物件會不會「逃出」目前函式(被回傳、被存到外部變數、被閉包捕獲…)。如果證明完全不會逃逸,TurboFan 甚至可以直接不配置這個物件在 Heap 上,改把它拆成幾個獨立的純量值(scalar,例如物件的每個欄位各自變成一個暫存器變數),完全跳過 Heap 配置與之後的垃圾回收成本——這比 Scope Analysis 那個「初步判定」更激進、更精確,因為它是根據實際執行 profile 做的優化,不是單純看語法結構。
  • Inline Caching(內聯快取,IC):物件的屬性存取(obj.x)如果每次遇到的物件形狀(Hidden Class)都一樣,V8 就把「這個屬性在記憶體的哪個偏移量」直接快取起來,之後同樣形狀的物件存取可以跳過查找過程直接取值——這是 V8(以及 Smalltalk 以降各種動態語言引擎)加速屬性存取的經典技巧。
    引擎會將原型鍊的結構也納入Shape的檢查中。只要原型鏈沒變,連原型的屬性與方法也會被IC快取為直接的記憶體編譯存取。
  • Type Specialization(型別特化):既然 Profiling Data 顯示這個函式「目前為止」呼叫時傳進來的都是同一種型別(例如都是 number),TurboFan 就假設這個前提永遠成立,生成一份專門針對這個型別、跳過泛用型別檢查的極致優化機器碼——這是換取速度的關鍵一步,但也正因為是「假設」,才需要下面的 Deoptimization 機制當安全網。

Deoptimization(去優化)—— TurboFan 賭錯的安全網

TurboFan 生成的優化機器碼,內部埋了「假設檢查點(deopt guard)」。一旦執行時真的違反了當初假設的前提(例如 Type Specialization 假設永遠是 number,結果這次傳進來的是 string),V8 會:

  1. 立刻放棄這份已經在跑的優化機器碼。
  2. 退回 Ignition,改用穩健、不做激進假設的 Bytecode 直譯執行,確保結果正確。
  3. 這個函式之後可能會重新被觀察、重新累積 Profiling Data,如果情況穩定下來,還是有機會再次被送去 TurboFan 優化(但太頻繁 deopt 的函式,V8 也可能乾脆放棄再嘗試優化它)。

這解釋了一個常見的效能建議「同一個函式盡量固定傳同一種型別」:不是因為 JS 語言本身要求型別固定,而是因為型別一直變會不斷觸發 deoptimization,讓 V8 反覆優化又放棄,效能反而比一直用 Ignition 直譯還差。

實例:拿掉 return 的無窮迴圈,OSR 實際發生的過程

出處:JavaScript-practicing/smallest-divisible-digit-product.js,把 return i; 拿掉後實測(見 [[JavaScript-字串方法]] 的 String(i) 段落)

var smallestNumber = function (n, t) {
    for (let i = n; ; i++) {           // 沒有終止條件
        const digits = String(i).split('');
        const product = digits.reduce((acc, digit) => acc * Number(digit), 1);
        if (product % t === 0) {
            // 拿掉 return i; 之後,這裡什麼都不做
        }
    }
};

實測跑起來 5 秒內沒結束,被系統丟到背景程序,之後手動強制終止才停下來。對照上面的管線:

  1. Parse + Ignition 生成 Bytecode:只做一次,不會每圈重做
  2. Ignition 直譯執行:逐行跑 String(i).split().reduce()i++,同時收集 Feedback Vector(iproduct 一直是 number)
  3. 迴圈 back-edge 計數超過門檻 → 觸發 OSR:迴圈還沒結束(沒有 return 可以走出函式),V8 直接在「執行中的這一幀」把 Ignition Bytecode 換成 TurboFan 優化機器碼,不用等函式 return
  4. TurboFan 型別特化:假設 i/product 永遠是 number,生出跳過泛用檢查的機器碼——迴圈跑更快,但邏輯上還是同一個沒有終止條件的迴圈,不會自己停
  5. GC 持續回收,但主執行緒被永久佔用digits/product 是短命值,Heap 不會爆掉,但單執行緒的 JS 沒有機會讓出控制權,process/分頁會整個卡死,只能靠外部強制終止(例如手動 kill 該 process)

一句話:OSR 讓迴圈「跑得更快」,但不會讓迴圈「知道該停」——終止條件永遠得靠程式邏輯自己(returnbreak)交代清楚,引擎不會幫你補上。

編譯期 vs 執行期:Creation Phase/Hoisting 算哪一邊?

快速結論(完整版見 [[函式呼叫核心機制-Execution-Context-與-Paramete


上一篇
Day 7 | HTML Parsing:瀏覽器拿到 HTTP response body 之後跟script搶用同一條線程的搭配組合
下一篇
Day 9 | HOC 高階組件與渲染劫持|反向繼承,以及 Vue / Angular 的對應機制
系列文
現代函式庫與JavaScript的關係9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言